Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin/demande/[ref] — la vue complète d'une demande : identité, contenu,3// métadonnées du formulaire, pièces jointes, workflow, notes internes,4// conversation et historique d'activité. L'ouverture marque la demande lue.5// Contrôle d'accès PAR demande : un admin sans droit reçoit un 404.6import { notFound, redirect } from "next/navigation";7import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";8import {9 findSubmissionByRef,10 attachmentsOf,11 notesOf,12 messagesOf,13 eventsOf,14 listAdmins,15 updateSubmission,16} from "@/lib/comms/db";17import {18 categoryBySlug,19 statusesFor,20 PRIORITIES,21 CONFIDENTIALITY_LEVELS,22} from "@/lib/comms/categories";23import SubmissionActions from "./SubmissionActions";2425export const dynamic = "force-dynamic";2627function fmtDate(iso: string): string {28 return iso.replace("T", " ").slice(0, 16);29}3031function fmtBytes(n: number): string {32 if (n < 1024) return `${n} o`;33 if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;34 return `${(n / 1024 / 1024).toFixed(1)} Mo`;35}3637function Meta({ label, value }: { label: string; value: React.ReactNode }) {38 if (value == null || value === "") return null;39 return (40 <div className="min-w-0">41 <p className="klabel">{label}</p>42 <p className="mt-[2px] text-[13.5px] break-words">{value}</p>43 </div>44 );45}4647export default async function SubmissionPage({48 params,49}: {50 params: Promise<{ ref: string }>;51}) {52 const session = await getAdminSession();53 if (!session) redirect("/admin/connexion");54 const ref = decodeURIComponent((await params).ref).slice(0, 40);55 const sub = findSubmissionByRef(ref);56 if (!sub || !canViewSubmission(session, sub)) notFound();5758 if (!sub.is_read) updateSubmission(sub.id, { is_read: 1 });5960 const cat = categoryBySlug(sub.category);61 const atts = attachmentsOf(sub.id);62 const notes = notesOf(sub.id);63 const messages = messagesOf(sub.id);64 const events = eventsOf(sub.id);65 const admins = listAdmins().filter((a) => a.active);66 const metadata = (() => {67 try {68 return JSON.parse(sub.metadata ?? "{}") as Record<string, unknown>;69 } catch {70 return {};71 }72 })();73 const tags = (() => {74 try {75 const t = JSON.parse(sub.tags ?? "[]") as unknown;76 return Array.isArray(t) ? t.filter((x): x is string => typeof x === "string") : [];77 } catch {78 return [];79 }80 })();81 // libellés humains des clés metadata, depuis le registre82 const fieldLabel = (key: string): string =>83 cat?.fields.find((f) => f.name === key)?.label ?? key;84 const displayName = sub.is_anonymous85 ? "Anonyme"86 : [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—";8788 return (89 <>90 <nav aria-label="Fil d'Ariane">91 <a href="/admin/inbox" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green">92 ← Inbox93 </a>94 </nav>9596 <div className="mt-4 flex flex-wrap items-center gap-3">97 <h1 className="gk-mono text-[clamp(17px,2.5vw,22px)] font-bold tracking-[0.06em] text-green">98 {sub.reference}99 </h1>100 <span className="adm-chip">{cat?.title ?? sub.category}</span>101 <span className={`adm-chip adm-chip--${sub.priority}`}>102 {PRIORITIES[sub.priority as keyof typeof PRIORITIES] ?? sub.priority}103 </span>104 <span className="adm-chip adm-chip--ghost">105 {statusesFor(sub.category)[sub.status] ?? sub.status}106 </span>107 <span className="adm-chip adm-chip--ghost">108 Confidentialité : {CONFIDENTIALITY_LEVELS[sub.confidentiality] ?? sub.confidentiality}109 </span>110 {sub.is_spam ? <span className="adm-chip adm-chip--urgente">Spam</span> : null}111 {sub.is_archived ? <span className="adm-chip adm-chip--ghost">Archivée</span> : null}112 {tags.map((t) => (113 <span key={t} className="adm-chip adm-chip--lime">#{t}</span>114 ))}115 <span className="gk-mono ml-auto text-[11px] text-ink-3">116 Reçue le {fmtDate(sub.created_at)}117 {sub.resolved_at ? ` · résolue le ${fmtDate(sub.resolved_at)}` : ""}118 </span>119 </div>120121 <div className="mt-6 grid gap-6 xl:grid-cols-[1fr_360px]">122 {/* colonne principale : identité + contenu + conversation + historique */}123 <div className="flex min-w-0 flex-col gap-6">124 <section className="gk-card p-5 !shadow-none">125 <p className="kicker">Identité</p>126 <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">127 <Meta label="Nom" value={displayName} />128 <Meta129 label="Courriel"130 value={sub.email ? <a className="text-green underline underline-offset-2" href={`mailto:${sub.email}`}>{sub.email}</a> : sub.is_anonymous ? "— (anonyme)" : null}131 />132 <Meta label="Téléphone" value={sub.phone} />133 <Meta label="Organisation" value={sub.organization} />134 <Meta label="Rôle / poste" value={sub.job_title} />135 <Meta136 label="KA ID"137 value={sub.ka_id ? <span className="gk-mono font-bold">{sub.ka_id}</span> : null}138 />139 <Meta140 label="Site web"141 value={sub.website ? <a className="text-green underline underline-offset-2" href={sub.website} target="_blank" rel="noopener noreferrer">{sub.website}</a> : null}142 />143 <Meta144 label="LinkedIn"145 value={sub.linkedin_url ? <a className="text-green underline underline-offset-2" href={sub.linkedin_url} target="_blank" rel="noopener noreferrer">{sub.linkedin_url}</a> : null}146 />147 <Meta148 label="GitHub / portfolio"149 value={sub.github_url ? <a className="text-green underline underline-offset-2" href={sub.github_url} target="_blank" rel="noopener noreferrer">{sub.github_url}</a> : null}150 />151 </div>152 </section>153154 <section className="gk-card p-5 !shadow-none">155 <p className="kicker">Contenu de la demande</p>156 <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">157 <Meta label="Sous-catégorie" value={sub.sub_category} />158 <Meta label="Site concerné" value={sub.site_concerned} />159 <Meta label="Sujet" value={sub.subject} />160 <Meta label="Source" value={sub.source} />161 </div>162 {sub.message ? (163 <div className="mt-5 border-l-2 border-green bg-surface-2 px-4 py-3">164 <p className="text-[14px] leading-relaxed whitespace-pre-wrap">{sub.message}</p>165 </div>166 ) : null}167 {Object.keys(metadata).length ? (168 <div className="mt-5">169 <p className="klabel">Métadonnées du formulaire</p>170 <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">171 {Object.entries(metadata).map(([k, v]) => (172 <Meta173 key={k}174 label={fieldLabel(k)}175 value={v === true ? "Oui" : String(v)}176 />177 ))}178 </div>179 </div>180 ) : null}181 {atts.length ? (182 <div className="mt-5">183 <p className="klabel">Pièces jointes</p>184 <ul className="mt-2 flex flex-wrap gap-2">185 {atts.map((a) => (186 <li key={a.id}>187 <a188 href={`/api/admin/attachments/${a.id}`}189 className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft"190 >191 ⭳ {a.original_name}192 <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span>193 </a>194 </li>195 ))}196 </ul>197 </div>198 ) : null}199 </section>200201 <section className="gk-card p-5 !shadow-none">202 <p className="kicker">Conversation ({messages.length})</p>203 {messages.length === 0 ? (204 <p className="gk-mono mt-3 text-[11.5px] text-ink-3">205 Aucun échange pour l'instant — répondez depuis le panneau de droite.206 </p>207 ) : (208 <ul className="mt-4 flex flex-col gap-3">209 {messages.map((m) => (210 <li211 key={m.id}212 className={`max-w-[85%] rounded-xl border-[1.5px] px-4 py-3 ${213 m.sender_type === "admin"214 ? "self-end border-ink bg-ink text-paper"215 : "self-start border-line bg-surface-2"216 }`}217 >218 <p className={`gk-mono text-[9.5px] font-bold tracking-[0.1em] uppercase ${m.sender_type === "admin" ? "text-lime" : "text-ink-3"}`}>219 {m.sender_type === "admin" ? (m.author ?? "Équipe KA") : displayName} · {fmtDate(m.created_at)} · {m.channel}220 </p>221 <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{m.body}</p>222 </li>223 ))}224 </ul>225 )}226 </section>227228 <section className="gk-card p-5 !shadow-none">229 <p className="kicker">Notes internes ({notes.length})</p>230 {notes.length === 0 ? (231 <p className="gk-mono mt-3 text-[11.5px] text-ink-3">Aucune note.</p>232 ) : (233 <ul className="mt-4 flex flex-col gap-3">234 {notes.map((n) => (235 <li key={n.id} className="border-l-2 border-amber bg-amber-soft px-4 py-3">236 <p className="gk-mono text-[9.5px] font-bold tracking-[0.1em] text-[#7a4d0d] uppercase">237 {n.author} · {fmtDate(n.created_at)} · interne238 </p>239 <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{n.body}</p>240 </li>241 ))}242 </ul>243 )}244 </section>245246 <section className="gk-card p-5 !shadow-none">247 <p className="kicker">Historique d'activité</p>248 <ul className="mt-4 flex flex-col">249 {events.map((e) => (250 <li key={e.id} className="flex items-baseline gap-3 border-t border-line py-2 first:border-t-0">251 <span className="gk-mono flex-none text-[10px] text-ink-3">{fmtDate(e.created_at)}</span>252 <span className="adm-chip adm-chip--ghost flex-none !text-[9px]">{e.kind}</span>253 <span className="min-w-0 text-[12.5px] text-ink-2">254 {e.detail}255 {e.author ? <span className="text-ink-3"> — {e.author}</span> : null}256 </span>257 </li>258 ))}259 </ul>260 </section>261 </div>262263 {/* colonne workflow */}264 <aside className="h-fit xl:sticky xl:top-8">265 <div className="gk-card p-5">266 <p className="kicker">Workflow</p>267 <div className="mt-4">268 <SubmissionActions269 id={sub.id}270 statuses={statusesFor(sub.category)}271 status={sub.status}272 priority={sub.priority}273 confidentiality={sub.confidentiality}274 assignedTo={sub.assigned_to}275 tags={tags}276 admins={admins.map((a) => ({ id: a.id, name: a.display_name }))}277 isArchived={!!sub.is_archived}278 isSpam={!!sub.is_spam}279 hasEmail={!!sub.email}280 />281 </div>282 </div>283 </aside>284 </div>285 </>286 );287}288